Micron Document
C.S.Burner 🪙 ━►🔥GIT Node

Node / reticulum_mirror / MeshChatX / files / meshchatx / src / frontend / components / NotificationBell.vue

Displaying Raw • Download

meshchatx/src/frontend/components/NotificationBell.vue 9ec2a88817819d433c5d68dbec0ca070ca989b91 (9ec2a888) Text, 20.98 KB

T8b949e<!-- SPDX-License-Identifier: 0BSD -->

Tff7b72<templateTff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffrelativeTa5d6ff"Tff7b72>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffrelative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colorsTa5d6ff"
@click.Te6edf3stop=Ta5d6ff"Ta5d6fftoggleDropdownTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffbellTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffw-5 h-5 sm:w-6 sm:h-6Ta5d6ff" Tff7b72/>
Tff7b72<span
Te6edf3v-if=Ta5d6ff"Ta5d6ffunreadCount > 0Ta5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffabsolute top-0 right-0 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs font-semibold text-whiteTa5d6ff"
Tff7b72>
{{ unreadCount > 9 ? "9+" : unreadCount }}
Tff7b72</span>
Tff7b72</button>

Tff7b72<Teleport Te6edf3to=Ta5d6ff"Ta5d6ffbodyTa5d6ff"Tff7b72>
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffisDropdownOpenTa5d6ff"
Te6edf3ref=Ta5d6ff"Ta5d6ffnotificationDropdownTa5d6ff"
Te6edf3v-click-outside=Ta5d6ff"Ta5d6ffcloseDropdownTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fffixed w-80 sm:w-96 md:max-lg:w-80 lg:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[min(500px,calc(100vh-2rem))] overflow-hidden flex flex-colTa5d6ff"
Te6edf3:style=Ta5d6ff"Ta5d6ffdropdownStyleTa5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffp-4 border-b border-gray-200 dark:border-zinc-800Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center justify-betweenTa5d6ff"Tff7b72>
Tff7b72<h3 Te6edf3class=Ta5d6ff"Ta5d6fftext-lg font-semibold text-gray-900 dark:text-whiteTa5d6ff"Tff7b72>NotificationsTff7b72</h3>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center gap-2Ta5d6ff"Tff7b72>
Tff7b72<button
Te6edf3v-if=Ta5d6ff"Ta5d6ffnotifications.length > 0 && !showHistoryTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fftext-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colorsTa5d6ff"
@click.Te6edf3stop=Ta5d6ff"Ta5d6ffclearAllNotificationsTa5d6ff"
Tff7b72>
Clear
Tff7b72</button>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffrounded-md p-1 transition-colorsTa5d6ff"
Te6edf3:class=Ta5d6ff"
Ta5d6ff showHistory
? Ta5d6ff'Ta5d6fftext-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950/40Ta5d6ff'
Ta5d6ff : Ta5d6ff'Ta5d6fftext-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800Ta5d6ff'
Ta5d6ff Ta5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffapp.notifications_history_titleTa5d6ff'Ta5d6ff)Ta5d6ff"
Te6edf3:aria-label=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffapp.notifications_history_titleTa5d6ff'Ta5d6ff)Ta5d6ff"
@click.Te6edf3stop=Ta5d6ff"Ta5d6fftoggleHistoryTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffhistoryTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffw-5 h-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fftext-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colorsTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffcloseDropdownTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcloseTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffw-5 h-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>

Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffoverflow-y-auto flex-1Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3v-if=Ta5d6ff"Ta5d6ffisLoadingTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffp-8 text-centerTa5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffinline-block animate-spin text-gray-400Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffrefreshTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffw-6 h-6Ta5d6ff" Tff7b72/>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffmt-2 text-sm text-gray-500 dark:text-gray-400Ta5d6ff"Tff7b72>Loading notifications...Tff7b72</div>
Tff7b72</div>

Tff7b72<div Te6edf3v-else-if=Ta5d6ff"Ta5d6ffnotifications.length === 0Ta5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffp-8 text-centerTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon
Te6edf3icon-name=Ta5d6ff"Ta5d6ffbell-offTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffw-12 h-12 mx-auto text-gray-400 dark:text-gray-500Ta5d6ff"
Tff7b72/>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffmt-2 text-sm text-gray-500 dark:text-gray-400Ta5d6ff"Tff7b72>
{{ showHistory ? Te6edf3$t("app.notifications_empty_history") : Te6edf3$t("app.notifications_no_new") }}
Tff7b72</div>
Tff7b72</div>

Tff7b72<div v-else Te6edf3class=Ta5d6ff"Ta5d6ffdivide-y divide-gray-200 dark:divide-zinc-800Ta5d6ff"Tff7b72>
Tff7b72<button
Te6edf3v-for=Ta5d6ff"Ta5d6ffnotification in notificationsTa5d6ff"
Te6edf3:key=Ta5d6ff"Ta5d6ffnotification.destination_hashTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffw-full p-4 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-leftTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffonNotificationClick(notification)Ta5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex gap-3Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex-shrink-0Ta5d6ff"Tff7b72>
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffnotification.lxmf_user_iconTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffp-2 rounded-lgTa5d6ff"
Te6edf3:style=Ta5d6ff"Ta5d6ff{
color: notification.lxmf_user_icon.foreground_colour,
Ta5d6ff'Ta5d6ffbackground-colorTa5d6ff'Ta5d6ff: notification.lxmf_user_icon.background_colour,
}Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon
Te6edf3:icon-name=Ta5d6ff"Ta5d6ffnotification.lxmf_user_icon.icon_nameTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffw-6 h-6Ta5d6ff"
Tff7b72/>
Tff7b72</div>
Tff7b72<div
v-else
Te6edf3class=Ta5d6ff"Ta5d6ffbg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded-lgTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffaccount-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffw-6 h-6Ta5d6ff" Tff7b72/>
Tff7b72</div>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex-1 min-w-0Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-start justify-between gap-2 mb-1Ta5d6ff"Tff7b72>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6fffont-semibold text-gray-900 dark:text-white truncateTa5d6ff"
Te6edf3:title=Ta5d6ff"Ta5d6ffnotification.custom_display_name ?? notification.display_nameTa5d6ff"
Tff7b72>
{{ notification.custom_display_name ?? notification.display_name }}
Tff7b72</div>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6fftext-xs text-gray-500 dark:text-gray-400 whitespace-nowrap flex-shrink-0Ta5d6ff"
Tff7b72>
{{ formatTimeAgo(notification.updated_at) }}
Tff7b72</div>
Tff7b72</div>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6fftext-sm text-gray-600 dark:text-gray-400 line-clamp-2Ta5d6ff"
Te6edf3:title=Ta5d6ff"
Ta5d6ff notification.latest_message_preview ?? notification.content ?? Ta5d6ff'Ta5d6ffNo previewTa5d6ff'
Ta5d6ff Ta5d6ff"
Tff7b72>
{{
notification.latest_message_preview ?? notification.content ?? "No preview"
}}
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</Teleport>
Tff7b72</div>
Tff7b72</template>

Tff7b72<scriptTff7b72>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import Utils from "../js/Utils";
import WebSocketConnection from "../js/WebSocketConnection";
import GlobalState from "../js/GlobalState";
import { clampFloatingToViewport } from "../js/clampFloatingToViewport.js";

export default {
name: "NotificationBell",
components: {
MaterialDesignIcon,
},
directives: {
"click-outside": {
mounted(el, binding) {
el.clickOutsideEvent = function (event) {
if (!(el === event.target || el.contains(event.target))) {
binding.value();
}
};
document.addEventListener("click", el.clickOutsideEvent);
},
unmounted(el) {
document.removeEventListener("click", el.clickOutsideEvent);
},
},
},
emits: ["notifications-cleared"],
data() {
return {
isDropdownOpen: false,
isLoading: false,
notifications: [],
unreadCount: 0,
reloadInterval: null,
dropdownPosition: { top: 0, left: 0 },
dropdownMaxHeight: null,
showHistory: false,
};
},
computed: {
dropdownStyle() {
const style = {
top: `${this.dropdownPosition.top}px`,
left: `${this.dropdownPosition.left}px`,
};
if (this.dropdownMaxHeight != null) {
style.maxHeight = `${this.dropdownMaxHeight}px`;
}
return style;
},
},
beforeUnmount() {
if (this.reloadInterval) {
clearInterval(this.reloadInterval);
}
WebSocketConnection.off("message", this.onWebsocketMessage);
},
mounted() {
this.loadNotifications();
WebSocketConnection.on("message", this.onWebsocketMessage);
this.reloadInterval = setInterval(() => {
if (this.isDropdownOpen) {
this.loadNotifications({ updateList: false });
}
}, 5000);
},
methods: {
shouldFetchNotifications() {
if (!GlobalState.authSessionResolved) {
return false;
}
if (!GlobalState.authEnabled) {
return true;
}
return GlobalState.authenticated;
},
async toggleDropdown(event) {
this.isDropdownOpen = !this.isDropdownOpen;
if (this.isDropdownOpen) {
this.showHistory = false;
this.updateDropdownPosition(event);
await this.loadNotifications();
const hadNotifications = this.notifications.length > 0;
await this.markNotificationsAsViewed();
if (hadNotifications) {
await this.loadNotifications({ updateList: false });
}
await this.$nextTick();
this.clampNotificationDropdown();
}
},
updateDropdownPosition(event) {
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
const isMobile = window.innerWidth Tff7b72< 640;
const Te6edf3dropdownWidth = Ta5d6ffisMobile ? 320 : 384;

Te6edf3this.dropdownMaxHeight = Ta5d6ffnull;
Te6edf3this.dropdownPosition = Ta5d6ff{
top: rect.bottom + 8,
left: Math.max(16, rect.right - dropdownWidth),
};
this.$nextTick(() =Tff7b72> this.clampNotificationDropdown());
},
clampNotificationDropdown() {
const panel = this.$refs.notificationDropdown;
if (!panel || !this.isDropdownOpen) return;
const pr = panel.getBoundingClientRect();
const { left, top, maxHeight } = clampFloatingToViewport(pr.left, pr.top, pr.width, pr.height);
this.dropdownPosition = { top, left };
this.dropdownMaxHeight = maxHeight;
},
closeDropdown() {
this.isDropdownOpen = false;
this.showHistory = false;
},
async toggleHistory() {
this.showHistory = !this.showHistory;
await this.loadNotifications();
if (!this.showHistory) {
const hadNotifications = this.notifications.length > 0;
await this.markNotificationsAsViewed();
if (hadNotifications) {
await this.loadNotifications({ updateList: false });
}
}
if (this.isDropdownOpen) {
await this.$nextTick();
this.clampNotificationDropdown();
}
},
async loadNotifications(options = {}) {
const updateList = options.updateList !== false;
if (!this.shouldFetchNotifications()) {
this.notifications = [];
this.unreadCount = 0;
this.isLoading = false;
return;
}
if (updateList) {
this.isLoading = true;
}
try {
const response = await window.api.get(`/api/v1/notifications`, {
params: {
unread: !this.showHistory,
limit: 10,
},
});
const newNotifications = response.data.notifications || [];
if (updateList) {
this.notifications = newNotifications;
}
this.unreadCount = response.data.unread_count || 0;
} catch (e) {
console.error("Failed to load notifications", e);
if (updateList) {
this.notifications = [];
}
} finally {
if (updateList) {
this.isLoading = false;
}
}
},
async markNotificationsAsViewed() {
if (!this.shouldFetchNotifications()) {
return;
}
if (this.notifications.length === 0) {
return;
}
try {
const destination_hashes = this.notifications
.filter((n) => n.type === "lxmf_message")
.map((n) => n.destination_hash);
const notification_ids = this.notifications.filter((n) => n.type !== "lxmf_message").map((n) => n.id);

await window.api.post("/api/v1/notifications/mark-as-viewed", {
destination_hashes: destination_hashes,
notification_ids: notification_ids,
});
} catch (e) {
console.error("Failed to mark notifications as viewed", e);
}
},
async clearAllNotifications() {
if (!this.shouldFetchNotifications()) {
return;
}
try {
await window.api.post("/api/v1/notifications/mark-as-viewed", {
destination_hashes: [],
notification_ids: [],
});

const response = await window.api.get("/api/v1/lxmf/conversations");
const conversations = response.data.conversations || [];

for (const conversation of conversations) {
if (conversation.is_unread) {
try {
await window.api.post(
`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`
);
} catch (e) {
console.error(`Failed to mark conversation as read: ${conversation.destination_hash}`, e);
}
}
}

GlobalState.unreadConversationsCount = 0;

this.showHistory = false;
await this.loadNotifications();
this.$emit("notifications-cleared");
} catch (e) {
console.error("Failed to clear notifications", e);
}
},
async onNotificationClick(notification) {
this.closeDropdown();

if (!this.shouldFetchNotifications()) {
return;
}

// Mark this specific notification as viewed
try {
const destination_hashes = notification.type === "lxmf_message" ? [notification.destination_hash] : [];
const notification_ids = notification.type !== "lxmf_message" ? [notification.id] : [];

await window.api.post("/api/v1/notifications/mark-as-viewed", {
destination_hashes: destination_hashes,
notification_ids: notification_ids,
});

// reload to update unread count
await this.loadNotifications();
} catch (e) {
console.error("Failed to mark notification as viewed", e);
}

if (notification.type === "lxmf_message") {
this.$router.push({
name: "messages",
params: { destinationHash: notification.destination_hash },
});
} else if (notification.type === "telephone_missed_call") {
this.$router.push({
name: "call",
query: { tab: "history" },
});
} else if (notification.type === "telephone_voicemail") {
this.$router.push({
name: "call",
query: { tab: "voicemail" },
});
}
},
formatTimeAgo(datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
isUserFacingLxmfDelivery(lxmfMessage) {
if (!lxmfMessage || lxmfMessage.is_incoming !== true) {
return false;
}
// Reactions never count as a notification.
if (lxmfMessage.is_reaction === true) {
return false;
}
const fields = lxmfMessage.fields || {};
const appExt = fields.app_extensions;
if (appExt && typeof appExt === "object" && Object.prototype.hasOwnProperty.call(appExt, "reaction_to")) {
return false;
}
const content = (lxmfMessage.content || "").toString().trim();
const title = (lxmfMessage.title || "").toString().trim();
if (content.length > 0 || title.length > 0) {
return true;
}
const image = fields.image;
if (image && (image.image_size || image.image_bytes)) {
return true;
}
const audio = fields.audio;
if (audio && (audio.audio_size || audio.audio_bytes)) {
return true;
}
const fileAttachments = fields.file_attachments;
if (Array.isArray(fileAttachments) && fileAttachments.length > 0) {
return true;
}
return false;
},
async onWebsocketMessage(message) {
if (!this.shouldFetchNotifications()) {
return;
}
let json;
try {
json = JSON.parse(message.data);
} catch {
return;
}
if (json.type === "lxmf.delivery") {
if (!this.isUserFacingLxmfDelivery(json.lxmf_message)) {
return;
}
await this.loadNotifications();
if (this.isDropdownOpen) {
const hadNotifications = this.notifications.length > 0;
await this.markNotificationsAsViewed();
if (hadNotifications) {
await this.loadNotifications({ updateList: false });
}
}
return;
}
if (json.type === "telephone_missed_call" || json.type === "new_voicemail") {
await this.loadNotifications();
if (this.isDropdownOpen) {
const hadNotifications = this.notifications.length > 0;
await this.markNotificationsAsViewed();
if (hadNotifications) {
await this.loadNotifications({ updateList: false });
}
}
}
},
},
};
Tff7b72</script>

Tff7b72<style scopedTff7b72>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
Tff7b72</style>

Served by rngit 1.5.2 - Generated in 0.04s